從 Day 04 到昨天,我們一次只順著一個問題往下走。每個領域的第一天,先把那個領域的問題寫成一句話;最後一天,再回頭看這幾天得到的答案。
昨天,真實 DOM 也告一段落了。現在,讓我們把這六句從各自的那一天拿出來,放在一起:
六句問的對象各不相同:一份遠端資料、一個活在 React 外面的 store、一份表單、localStorage、一個下拉選單,以及一份清單。可是讀下來,有幾個字一直回來。
重複出現的,是哪幾個詞?
只找重複的字,可能還不夠。有些問句用了同一個字,問的卻不是同一件事;有些問句看起來不同,答案卻一再回到同一個地方。
第一句問「誰的」,第二句問「哪些人」,第三句問「誰」。
這三句,我們在 Day 16 的開頭並排過一次。當時,我們把它們讀成同一件事:前三個領域一直在回答「誰該知道」。
可是再讀一次第一句。「這份資料是誰的」,問的真的是誰該知道嗎?
回到探索遠端世界的 Day 05。我們在每一份資料身上記了一個 observers,數有幾個元件正在看它;人數歸零時,GC_TIME 的計時才開始。這個數字記下的,是目前有多少元件在看。
但 Day 07 把四條紅掉的斷言並排之後,得到的是另一句話:這個版本有快取,但快取沒有擁有者。那一天的結論是,有人得擁有這份資料的生命週期,負責判斷它何時算舊、何時重新取得,以及沒有人再看時要留多久。
同一個「誰」,問的是兩件事。
第二句的「哪些人」與第三句的「誰」,比較接近「誰在看」:值變了之後,要通知誰。第一句的「誰」,則是「誰替它負責」。
把「誰」先放到一邊,還有另一組字在重複:第二句的「變了」,第三句的「每次擊鍵」,第六句的「畫出來之後」與「還算數嗎」。
它們都在追問:先前讀到的值、算出的結果,什麼情況下需要重新判斷?
這個問法也不是今天才出現。Day 05 問過,這份資料什麼時候算舊;探索共享狀態的 Day 09 用 Object.is 比對選擇器的結果,判斷元件在乎的那一塊有沒有改變;Day 10 看著購物車從 1 變成 2,而 hasItemsAtom 的值沒變,失效在它那裡就停了。
「失效」這個詞,我們其實已經用過好幾次,只是它一直待在各自的那一天。
有時是時間到了,有時是依賴的值變了。到了真實 DOM,則是畫面量出來的尺寸,推翻了原本的估算。
它們需要重新判斷的理由不同,但都得回答:先前的結果,還能不能繼續用?
第四句和第五句,乍看沒有「誰」,也沒有時間。
「包一層,就算封裝了嗎?」「複雜度會跑去哪裡?」問的是包起來的那個東西本身:包的到底是什麼,複雜度最後落在哪裡。
瀏覽器 API 的最後一天,我們給過這樣的答案:深度不在 getItem 與 setItem 的包裝,而在於認出「一個 Storage 的一個 key」,才是真正的抽象單位。
這一組重複的東西,不太在問句的字面上,比較常出現在答案裡。
不過第五句還留著一個字:「你」。「如果 DOM 由你決定」,這個「你」也是一個「誰」。Day 20 把它換了個方式說:「不決定你的 DOM」,是 DOM 的結構歸你。
這一次,歸誰決定的不是資料的生命週期,而是 DOM 的結構。同樣在問所有權,只是擁有的東西換了。
把剛才找到的東西收起來,根據我目前的歸納,六句裡重複的大概是四種說法:
接下來,我把它們叫做四條軸:訂閱 (subscription)、所有權 (ownership)、失效 (invalidation) 與抽象 (abstraction)。
它們看起來像一份新的清單,但回答的其實是一個舊問句。
它們和 Day 02 的三個詞,有什麼關係?
Day 02 給了我們看模組的方法:複雜度來自哪裡、哪些事情被藏起來、介面能替呼叫端承擔多少。這四條軸,則讓「藏了什麼」變得具體。
遠端世界的所有權模型,藏起了觀察者人數與兩個計時器;共享狀態的答案,藏起了寫入之後會傳到哪裡,以及元件依賴了什麼;使用者輸入的 RHF,藏起了訂閱關係的建立方式;瀏覽器 API 的 @react-hookz/web,藏起了同一個 key 的訂閱與去重。
原本散在不同領域的答案,現在有了可以放在一起讀的地方。
把這些答案攤開,它們大多落在剛才那四種說法裡。
但放在一起之後,還有一件事值得看:四條軸在這六個領域裡,並沒有受到同樣多的追問。
先看所有權。它在遠端世界當過一次主角,後面的領域仍然有擁有者,只是我們比較的實作,多半在這件事上採取了相近的安排。
共享狀態的最後一天,我們寫下:「這份狀態是誰的」在這裡並不是爭點,三份臨摹都把狀態放在 React 外面的 store 裡,差別全在誰該被通知。
使用者輸入的 Day 13,RHF 與 TanStack Form 都把表單狀態放在 React 外面。
瀏覽器 API 的 Day 16,theme 存在瀏覽器的 localStorage 裡,不屬於任何一個元件;這是問題成立的前提。
真實 DOM 的四天裡,量到的尺寸也都由清單的內部狀態保存,我們比較的是怎麼記,而不是交給誰記。
所有權仍然在那裡,只是沒有成為這幾組比較的主要分歧。
互動行為則把「誰的」帶了回來:DOM 的結構由呼叫端決定,函式庫提供的行為要怎麼接上去?這一次,所有權的對象從狀態換成了結構。
所以在這次探索裡,所有權比較像是先確認責任落在哪裡,再往下看其他差異。
訂閱與失效,則是在責任已經有了安排之後,仍然被一路追問的兩條軸。
遠端世界問的,是有誰在聽。Day 05 的 observers,數的就是目前有多少元件正在看這份資料。
共享狀態問的,是怎麼聽、聽得多細。Day 08 先讓 useSyncExternalStore 把活在 React 外面的 store 接進來;Day 09 讓每個元件用選擇器說出自己在乎哪一塊;Day 10 把在乎的單位換成圖上的節點;Day 11 則讓元件讀過什麼,就算訂閱了什麼。
使用者輸入問的,是這份訂閱關係從哪裡來。Day 13 裡,RHF 在元件渲染時記下它讀過哪些 key;TanStack Form 則要呼叫端事先用選擇器宣告。那一天的結論是,真正的差別不是誰比較快,而是這份訂閱關係由誰維護。
走到這裡,我們已經問過訂閱的三個面向:誰在聽、聽得多細,以及訂閱關係由誰寫下。
後面的領域仍然需要訂閱,只是在這次探索裡,它們大多沿用了這三種問法。
互動行為的 Day 21 寫過,誰該被通知,在那裡是由交付的形狀決定的。真實 DOM 的 Day 24 則問「誰被觀察」:每一列各自被看著,還是只看著裝列的容器。
後者仍然在問聽得多細,只是聽的對象,從 store 換成了瀏覽器的版面。
訂閱這條軸,在前三個領域問完了三個面向;之後的領域,用的都還是這三種問法。
失效的走法不太一樣。
遠端世界問的,是何時算舊。Day 05 用 STALE_TIME 判斷資料何時變舊;Day 06 則是寫入之後,由我們主動說「它已經不對了」,再讓這句話蓋住前綴相符的那幾格。
共享狀態問的,是什麼算變了。Day 09 用 Object.is 比對選擇器的結果;Day 10 的失效沿著節點之間的邊往下走,走到值沒變的節點就停下來。
使用者輸入則把重新判斷的時機交給驗證模式。Day 14 的三種模式,分別在寫入時、離開欄位時,以及送出時驗證。它們決定的是哪一次互動之後,要重新判斷欄位是否合法,以及錯誤訊息何時更新。
資料算舊、選擇器結果改變、欄位重新驗證,不是同一種機制。但在這幾個例子裡,重新判斷的起點,都來自畫面輸出以外:時間到了、資料被寫入,或使用者做了一次互動。
到了瀏覽器 API,訂閱與失效在一個地方接了起來。
把前三個領域的問題排在一起:遠端世界問,誰擁有這份資料;共享狀態問,哪些人需要知道;使用者輸入問,資料高速變動時,哪些人需要知道。
使用者輸入的最後一天,又往下推了一步:瀏覽器替我們保管的東西,如果也根本不需要持續知道呢?
Day 16 的答案是:它也需要。同一個 key 被多個元件使用時,一邊寫入,另一邊也得更新。
有了不只一個觀察者,下一個問題才跟著出現:如果值被刪掉,另一邊該怎麼辦?
Day 18 讓另一個分頁呼叫 removeItem。瀏覽器送來的 storage 事件裡,newValue 是 null,而 @react-hookz/web 在 newValue !== null 這個條件下,把這個事件擋了下來。在三份實作裡藏起最多東西的 @react-hookz/web,在這一列和沒有監聽任何事件的 react-use 一樣是紅的。
這一次,值不是變了,而是不見了。
Day 19 回頭整理這個領域時,也把這件事留了下來:值被刪掉的時候,另一個觀察者要不要跟著退回預設值,是在這裡才變成問題的。
瀏覽器 API 這四天,把「誰需要知道」推到了「值不存在時,該知道什麼」。以下我把這個轉折稱為樞紐。
走過樞紐之後,到了真實 DOM,失效又換了一次樣子。
Day 23 先假設每一列都是 50px,替每一列算好位置。可是第 3 列一畫出來就折成三行,第 4 列的位置也就錯了。清單手上只有原本的估算,還不知道這件事。
這一次,推翻估算的,是我們自己畫出來的東西。
到了 Day 26,量測與渲染的迴圈轉了起來。畫面被量測,尺寸被記下,新的尺寸又決定下一次怎麼畫。
在那個實驗裡,讓兩份臨摹停下來的,是認出新的回報沒有改變尺寸狀態:TanStack 臨摹的 if (delta === 0) return;,以及 Virtuoso 臨摹的 reduceSizes 在尺寸沒變時交回同一個陣列。
這其實也回到了共享狀態問過的那一句:什麼算變了。
只是這次,讓它變的不是一次外部寫入,而是輸出回到了輸入。如果不能認出什麼沒有變,量測與渲染就可能繼續把彼此推下去。
失效這條軸,一路換了要處理的情境:先前的結果何時需要重看、值不存在時怎麼辦,以及自己的輸出推翻自己的估算時,迴圈在哪裡停下來。
回頭看這六個領域,四條軸並不是四條平均鋪開的線。
所有權讓我們先確認責任落在哪裡;訂閱在前半段建立了三種問法,後半段換了觀察的對象;失效則走過樞紐,到了真實 DOM,還在增加需要處理的情境。
我們今天找到的,不只是幾個重複出現的詞,也是這些詞各自走了多遠。
還有一條軸,它不是一路累積過來的,而是在樞紐那裡變得清楚了。
Day 19 說,真正的抽象單位,是「一個 Storage 的一個 key」。
為什麼認出這個單位,會改變模組能替呼叫端承擔的事情?
💡 六句問題句依序出自 Day 04、Day 08、Day 12、Day 16、Day 20、Day 23 的開頭,也就是 Day 01 列出的那六句。文中引用的結論分別在:「快取沒有擁有者」Day 07、「並不是爭點」Day 11、「抽象單位」Day 19、「誰該被通知由交付的形狀決定」Day 21、「認出新的回報沒有改變尺寸狀態」Day 26。這一篇沒有新的程式碼。